Android
Memory
Leaks
Objects that should be dead, kept alive by forgotten references. A complete guide to understanding, detecting, and eliminating every category of memory leak in Android — including how GC pressure leads to OOM and ANR.
Android Memory Model
Android runs each app in a sandboxed ART process with a fixed heap limit — from 32 MB on older devices to 512 MB+ on modern flagships. Cross this limit and the OS throws OutOfMemoryError, crashing your app immediately.
The heap is divided into generations. The Young generation holds short-lived objects collected quickly and cheaply. The Old generation holds long-lived tenured objects — this is where leaked objects accumulate and where GC becomes expensive.
Bitmaps changed in Android 8 (Oreo): Before API 26, bitmap pixel data lived on the Java heap — a 16 MB image consumed 16 MB of your heap limit directly. From API 26+, pixel data moved to native memory. Bitmap leaks no longer cause Java OOM directly, but they still cause native OOM and increase device memory pressure. Always use an image loading library.
GC, Roots & Reachability
The garbage collector works by tracing all object references starting from GC Roots. Any object reachable from a root is considered alive and will not be collected. Memory leaks occur when a logically dead object is still reachable from a root through a forgotten reference chain.
Definition: A memory leak is an object that is no longer needed by your application, but is still reachable from a GC root. The GC cannot collect it. It accumulates in the Old generation. Eventually your heap fills and the app crashes with OutOfMemoryError.
GC Roots in Android
The leak chain always has the same structure: GC Root → long-lived object → leaked object. Example: static field → Singleton → Activity. The Activity is done but the Singleton holds it. GC traces from the static field, marks the Activity alive. Its entire 50 MB view hierarchy stays in memory — per rotation.
GC Deep Dive — OOM & ANR
The garbage collector is your ally — but memory leaks turn it into your enemy. Understanding the full cycle from allocation to collection to crash explains exactly why leaks produce the specific symptoms they do: sluggishness, jank, and ultimately a process kill.
The full allocation & collection cycle
Every new Object() is born in Eden space. ART uses a bump-pointer allocator — just increment a pointer. Extremely fast. When Eden fills, a Minor GC fires automatically.
ART traces GC roots through the Young generation only. Live objects are copied to a Survivor space. Dead objects in Eden are discarded instantly — no deallocation cost. Objects surviving multiple cycles are promoted to Old generation. Typical pause: 1–5 ms. You never notice this.
When Old generation fills, ART triggers a Full GC — traces the entire heap. ART's concurrent GC (Android 5+) runs most phases alongside your app, but still has mandatory stop-the-world checkpoints. Typical pause: 10–100 ms. On a leaked heap full of dead-but-reachable objects, GC takes much longer because it must trace every leaked object before determining nothing can be freed.
When the heap is near-full from leaked objects, the allocator runs GC before every new allocation. Each GC frees almost nothing — leaked objects survive every collection. The VM spends more time collecting than executing your code. CPU spikes to 100%. UI thread is blocked constantly. This is GC thrashing — the direct precursor to OOM and the cause of most leak-driven ANRs.
After multiple failed GC attempts, the VM throws OutOfMemoryError. You cannot meaningfully catch and recover from this — the process is doomed. Triggered by any allocation that pushes usage past Runtime.maxMemory(): decoding a bitmap, inflating a layout, creating a large array.
Interactive — GC pause impact visualizer
Drag the slider to simulate leaked Activities accumulating in the Old generation. Watch how GC pause times grow, frame rendering degrades, and the path to ANR and OOM shortens.
ART GC algorithms — which one is running
System.gc() in production code.OOM anatomy — the 5-phase timeline
Minor GC collects in under 5 ms. Old generation has room. Heap: 20–40 MB. GC log shows Sticky CC. App fully responsive.
User rotates device 5 times. 5 leaked Activities × ~22 MB = 110 MB in Old generation. Major GC fires more frequently. Pauses grow to 50–200 ms. Logcat shows repeated GC_FOR_ALLOC. App feels sluggish.
Old generation 80%+ full. GC runs after every allocation — 100–500 ms each, freeing almost nothing. App spends more time in GC than executing. Frames take 200 ms+. UI visibly frozen. This is where users leave one-star reviews.
App tries to decode a thumbnail or inflate a layout. Allocator needs 8 MB. GC runs, frees 2 MB — not enough. GC runs again. Still not enough. After 3–5 failed attempts, ART prepares to throw.
java.lang.OutOfMemoryError: Failed to allocate a 8388608 byte allocation with 2097152 free bytes and 12MB until OOM — process killed. The leaked memory was never the immediate trigger. It was the slow invisible pressure that made the final allocation impossible.
Dissecting the OOM error message
ActivityManager.getMemoryClass(). Entirely consumed by accumulated leaked objects.From GC to ANR — how memory pressure freezes the UI
An Application Not Responding (ANR) fires when the main thread is blocked for more than 5 seconds. Memory leaks are a major hidden ANR cause. The connection is not obvious until you understand how GC interacts with the UI thread.
The GC-ANR connection: Even ART's concurrent GC has mandatory stop-the-world checkpoints. On a heap bloated with leaked objects, GC takes much longer to complete — and those checkpoints block the main thread proportionally. 10 leaked Activities can turn a 5 ms GC pause into a 200 ms freeze. Repeated every few seconds — that is an ANR.
GC_FOR_ALLOC — a blocking GC that runs synchronously on the requesting thread. If the main thread is decoding a bitmap or inflating a layout, it freezes until GC completes. All heavy allocation must happen on background threads.finalize() methods (Cursor, pre-API26 Bitmap) go through a FinalizerDaemon thread before being truly collected. If this daemon falls behind — common during GC thrashing — the finalizer queue grows and the heap appears full even though objects are technically unreachable. A hidden source of memory pressure.Reading GC signals in Logcat
ART logs every GC event. Run adb logcat | grep -E "art|GC|heap" while exercising your app. Steadily growing heap size and increasing pause times are the first visible warning signs of a leak — before any crash occurs.
// Format: GC_REASON(GC_TYPE) freed K(Y%) paused Xms+Xms total Xms I/art: GC_CONCURRENT(sticky) freed 4MB (31%), paused 1.5ms+0.5ms, total 18ms // ✓ HEALTHY — Sticky CC fast path. Tiny pause. Lots freed. I/art: GC_FOR_ALLOC(full) freed 512KB (2%), paused 120ms+45ms, total 165ms // ⚠ WARNING — Allocator failed. Blocking GC ran synchronously on calling thread. // If that thread was Main — UI was frozen for 165ms. I/art: Heap size=128MB, alloc=124MB, free=4MB (3%) I/art: GC_BEFORE_OOM freed 256KB (0%), paused 348ms, total 350ms // ✗ CRITICAL — Last-ditch GC. Freed almost nothing. OOM imminent. E/art: Out of memory on a 8388616-byte allocation. java.lang.OutOfMemoryError: Failed to allocate a 8388616 byte allocation with 1048576 free bytes and 4MB until OOM, max allowed footprint 134217728 // GC_REASON reference: // GC_CONCURRENT — background GC, proactive, non-blocking // GC_FOR_ALLOC — allocator failed, blocking on requesting thread ← worst // GC_EXPLICIT — System.gc() was called — never do this in prod // GC_BEFORE_OOM — last-ditch attempt, almost always followed by OOM // GC_HPROF_DUMP — heap dump (LeakCanary triggers this)
Reading an ANR trace — main thread blocked by GC
// ANR in com.myapp — Input dispatching timed out // ─── MAIN THREAD (tid=1) — your blocked UI thread ─── "main" prio=5 tid=1 WaitingForGcToComplete | state=S at java.lang.Object.wait!(Object.java:-2) at com.android.internal.os.BinderInternal.waitForGcToComplete ← waiting for GC! at android.os.Binder.blockUntilThreadAvailable (Binder.java:... // ─── GC THREAD — the cause ─── "HeapTaskDaemon" prio=5 tid=6 Runnable | state=R (running — running full Mark-Compact on 120MB bloated heap) at dalvik.system.VMRuntime.runFinalization // What to look for in a GC-caused ANR: // "WaitingForGcToComplete" in main thread state // "HeapTaskDaemon" thread is Runnable (running GC) // Large heap size in the trace header // FinalizerDaemon backed up with many pending objects
Measuring heap pressure in code
val rt = Runtime.getRuntime() val maxHeap = rt.maxMemory() // process heap limit e.g. 128 MB val totalHeap = rt.totalMemory() // current total (grows up to max) val freeHeap = rt.freeMemory() // free within current total val available = freeHeap + (maxHeap - totalHeap) // true headroom val am = getSystemService(ActivityManager::class.java)!! val heapMB = am.memoryClass // soft per-app limit for device tier // System RAM pressure val info = ActivityManager.MemoryInfo() am.getMemoryInfo(info) val lowMemory = info.lowMemory // true = LMK is actively killing processes // The rotation test — run this before and after rotating 5 times // $ adb shell dumpsys meminfo com.myapp | grep "Java Heap" // Java Heap PSS growing 20-50MB per rotation = confirmed leak if (BuildConfig.DEBUG) { val pct = ((maxHeap - available) * 100.0 / maxHeap).toInt() Log.d("Heap", "$pct% used — ${available/1024/1024}MB free of ${maxHeap/1024/1024}MB") }
android:largeHeap="true" requests a larger heap from the manifest. This delays OOM but does not fix leaks. It also signals to the OS that your app is memory-hungry — making your process a higher-priority target for the Low Memory Killer when system RAM is low. Fix the leaks instead of enlarging the bucket.
Static Reference Leaks
Static fields live for the lifetime of the process. Anything stored in a static field — or in a singleton backed by a static field — must never hold a reference to an Activity, Fragment, View, or Context. These have lifecycle-bound lifetimes. Static fields do not.
object AnalyticsManager { var currentActivity: Activity? = null // rotates once = 1 leaked Activity + full hierarchy } companion object { private var cachedView: View? = null // View holds Context (Activity) → leak chain } class ImageLoader(val context: Context) { companion object { fun init(ctx: Context) = ImageLoader(ctx) // accidentally passing Activity context } }
object AnalyticsManager { private var ref: WeakReference<Activity>? = null fun attach(a: Activity) { ref = WeakReference(a) } fun detach() { ref = null } } fun init(ctx: Context) = ImageLoader(ctx.applicationContext) // Application context, safe
Inner Class Leaks
Non-static inner classes and anonymous classes hold an implicit reference to their enclosing outer class. If the inner class outlives the outer — via Handler, background thread, or stored callback — the outer class is leaked.
class SplashActivity : AppCompatActivity() { inner class SplashHandler : Handler(Looper.getMainLooper()) { override fun handleMessage(msg: Message) { navigate() } // holds SplashActivity } private val handler = SplashHandler() }
// Modern: use lifecycleScope — automatically cancelled in onDestroy
private fun startTimer() {
lifecycleScope.launch { delay(5000); navigate() }
}Modern advice: Replace all Handler.postDelayed in Activities and Fragments with lifecycleScope.launch { delay(ms); doWork() }. The coroutine is automatically cancelled when the lifecycle is destroyed — no manual cleanup needed.
Context Leaks
Context is the single most leaked object type in Android. There are two fundamentally different kinds. Storing the wrong one in a long-lived object causes leaks every time.
@HiltViewModel class GoodViewModel @Inject constructor( @ApplicationContext private val ctx: Context // Application context, safe ) : ViewModel() override fun onDestroy() { dialog?.dismiss(); dialog = null // always dismiss dialogs super.onDestroy() }
Listener & Callback Leaks
Registering a listener creates a reference from the system to your Activity. Forgetting to unregister keeps that reference alive long after the Activity should be dead. Extremely common with BroadcastReceivers, LocationManager, SensorManager, and custom event buses.
// ✓ Symmetric — onStart/onStop, onResume/onPause override fun onStart() { super.onStart() registerReceiver(receiver, filter) locationManager.requestLocationUpdates(provider, 0, 0f, locationListener) } override fun onStop() { unregisterReceiver(receiver) locationManager.removeUpdates(locationListener) super.onStop() } // ✓ Better — Lifecycle-aware observer auto-unregisters lifecycle.addObserver(LocationObserver(locationManager))
Coroutine & Scope Leaks
Coroutines in the wrong scope — or flow collection without lifecycle awareness — are the most modern and most underestimated leak category. They can keep entire coroutine contexts, suspend functions, and all captured closures alive indefinitely.
// ✗ GlobalScope — never cancelled, outlives Fragment GlobalScope.launch { val data = repo.fetch(); binding.tv.text = data } // ✗ collectAsState — keeps collecting even when UI is in background val state = viewModel.flow.collectAsState() // ✓ lifecycleScope — cancelled in onDestroy automatically // ✓ viewLifecycleOwner.lifecycleScope — cancelled in onDestroyView // ✓ viewModelScope — cancelled in onCleared override fun onViewCreated(view: View, state: Bundle?) { viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.uiState.collect { render(it) } } } } // ✓ In Compose — lifecycle-aware collection val state = viewModel.flow.collectAsStateWithLifecycle()
Bitmap & Drawable Leaks
Bitmaps are the largest objects in most Android apps. A single 2048×2048 ARGB_8888 image consumes 16 MB. Multiply by cached thumbnails and background images and you have the #1 source of OOM crashes — especially on devices below API 26 where bitmap data still lives on the Java heap.
// ✓ Always use an image loading library — Coil, Glide, or Picasso // They handle: lifecycle-awareness, caching, down-sampling, and recycle binding.imageView.load(url) { crossfade(true) size(ViewSizeResolver(binding.imageView)) // auto-samples to view size } // Coil: request cancelled when Fragment is destroyed // ✓ If manual, always clean up in onDetachedFromWindow override fun onDetachedFromWindow() { bitmap?.recycle(); bitmap = null super.onDetachedFromWindow() }
Compose-specific Leaks
Compose has a different memory model than Views but introduces its own patterns — particularly around effects, state, and the Compose/View interop boundary.
// ✗ DisposableEffect with empty onDispose — listener never removed DisposableEffect(vm) { vm.addListener(listener) onDispose { } // EMPTY — listener lives forever } // ✓ Always pair setup with cleanup DisposableEffect(vm) { vm.addListener(listener) onDispose { vm.removeListener(listener) } } // ✗ collectAsState — keeps collecting in background, wastes CPU val state = vm.flow.collectAsState() // ✓ collectAsStateWithLifecycle — stops when UI goes background val state = vm.flow.collectAsStateWithLifecycle()
Heap Simulator
Watch your heap grow as you create leaks. Observe GC events that free almost nothing, and see the moment the heap exhausts.
Detection Tools
detectLeakedClosableObjects() and detectLeakedSqlLiteObjects(). Crashes or logs when you forget to close streams, cursors, or SQLite connections.LeakCanary Internals
Understanding how LeakCanary works helps you interpret its output and configure it for advanced use cases.
// build.gradle.kts — debug only, zero app code needed debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14") // What LeakCanary does automatically: // 1. Hooks ActivityLifecycleCallbacks — watches every Activity // 2. After onDestroy(), waits 5 seconds // 3. Creates WeakReference to the Activity // 4. Forces GC, checks if WeakReference was cleared // 5. If not cleared → suspected leak → dumps HPROF // 6. Analyzes heap → finds shortest path from GC root to leaked object // 7. Shows notification with full reference chain // Reading a LeakCanary trace: // ┬────────────────────────────────────────────────── // │ GC Root: static field AnalyticsManager.INSTANCE // │ ↓ // │ AnalyticsManager.context ↓ ← LEAK SUSPECT // │ ↓ // ╰→ MainActivity (5 instances, 48 MB retained) // ┴────────────────────────────────────────────────── // Watch custom objects too override fun onDestroy() { super.onDestroy() AppWatcher.objectWatcher.expectWeaklyReachable(this, "MyService destroyed") }
Retained size vs shallow size: "48 MB retained" means the total memory freed if the leaked object were collected — including everything only reachable through it. The shallow size is just the object itself. Always look at retained size to understand the real impact of a leak.
Prevention Checklist
Context rules
Lifecycle rules
this) when observing LiveData in FragmentsCoroutine rules
All leak types — quick reference
| Leak Type | Severity | Root Cause | Fix |
|---|---|---|---|
| Static Activity ref | CRITICAL | Static field / singleton holds Activity | ApplicationContext or WeakReference |
| Non-static inner class | CRITICAL | Implicit this capture | Make static/top-level, WeakReference |
| Context in ViewModel | CRITICAL | ViewModel outlives Activity | @ApplicationContext via Hilt |
| Unregistered listeners | HIGH | Register without unregister | Symmetric lifecycle calls |
| GlobalScope coroutines | HIGH | Coroutine never cancelled | viewModelScope / lifecycleScope |
| ViewBinding in Fragment | HIGH | Not nulled in onDestroyView | _binding = null in onDestroyView |
| Bitmap not recycled | HIGH | Large objects held after use | Use Coil/Glide, recycle manually |
| Dialog not dismissed | HIGH | Window token held | dismiss() in onDestroy |
| DisposableEffect empty | MEDIUM | Empty onDispose block | Always pair setup with cleanup |
| collectAsState in Compose | MEDIUM | Collects in background | collectAsStateWithLifecycle() |
| Unclosed streams/cursors | MEDIUM | No close() call | use { } / try-finally |